iT邦幫忙

2025 iThome 鐵人賽

DAY 22
1
Modern Web

Angular 進階實務 30天系列 第 22

Day 22:Angular Reactive Forms –群組驗證與跨欄位邏輯

  • 分享至 

  • xImage
  •  

前言:從巢狀結構到驗證規則

上一篇談過 單層 vs 巢狀表單,用 FormGroupFormArray 對應資料模型。結構搞定後,下一步就是 驗證

  • 「這個欄位必須要有值嗎?」
  • 「這兩個欄位邏輯會不會互相矛盾?」
  • 「至少要填其中一個?」
  • 「當題目 A 選 1 時,題目 B 要必填;選 2 時就不必填?」

除了欄位級(FormControl)驗證,實務上更常見 群組級(FormGroup 驗證與 條件式 邏輯。

  1. 欄位級 vs 群組級驗證的差異(ng-zorro 錯誤呈現)
  2. 常見跨欄位邏輯(日期區間、密碼確認)
  3. 擇一必填(Email 或手機至少填一個)
  4. 條件必填(題目 A 決定題目 B 是否必填)
  5. 錯誤訊息如何設計與呈現

網頁參考:Day22


1. 欄位級驗證:單一欄位規則

最簡單的驗證加在 FormControl 上,例如:使用者名稱必填、Email 格式檢查。ng-zorro 以 nz-formnz-form-itemnz-form-controlnzErrorTip 呈現錯誤。

範例(含自訂欄位級驗證:身分證格式)

驗證規則

  • 首尾為英文字母
  • 中間為 8 碼數字
// validators.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';

export function idValidator(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string | null | undefined;
  if (!value) return null;
  const regex = /^[A-Z]{1}[0-9]{8}[A-Z]{1}$/;
  return regex.test(value) ? null : { idFormat: '身分證需符合「首尾英文字母,中間 8 碼數字」' };
}

// profile.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { CommonModule } from '@angular/common';
import { idValidator } from './validators';

@Component({
  standalone: true,
  selector: 'app-profile',
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
  template: `
  <form nz-form nzLayout="vertical" [formGroup]="form">
    <nz-form-item>
      <nz-form-label nzFor="username" nzRequired>使用者名稱</nz-form-label>
      <nz-form-control [nzErrorTip]="'請輸入使用者名稱'">
        <input nz-input id="username" formControlName="username" placeholder="請輸入名稱" />
      </nz-form-control>
    </nz-form-item>

    <nz-form-item>
      <nz-form-label nzFor="nationalId">身分證</nz-form-label>
      <nz-form-control [nzErrorTip]="idErrorTpl">
        <input nz-input id="nationalId" formControlName="nationalId" placeholder="如 A12345678B" />
        <ng-template #idErrorTpl>
          <ng-container *ngIf="form.get('nationalId')?.errors?.['idFormat'] as msg">{{ msg }}</ng-container>
        </ng-template>
      </nz-form-control>
    </nz-form-item>
  </form>
  `
})
export class ProfileComponent {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    username: ['', Validators.required],
    nationalId: ['', idValidator]
  });
}

重點:把錯誤訊息字串直接從驗證器回傳,UI 不用再組字串,避免誤解。


2. 群組級驗證:跨欄位邏輯(日期區間、密碼確認)

群組級驗證綁在 FormGroupvalidators 屬性上,用 group.get('key') 取值。

案例 A:日期區間(開始 ≤ 結束)

// validators.ts(節錄)
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export const dateRangeValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const start = group.get('startDate')?.value;
  const end = group.get('endDate')?.value;
  if (!start || !end) return null;
  return new Date(start) <= new Date(end) ? null : { dateRange: '結束日期必須晚於或等於開始日期' };
};

// period.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
import { dateRangeValidator } from './validators';

@Component({
  standalone: true,
  selector: 'app-period',
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzDatePickerModule, NzAlertModule],
  template: `
  <form nz-form nzLayout="vertical" [formGroup]="form">
    <div formGroupName="period">
      <nz-form-item>
        <nz-form-label nzFor="startDate" nzRequired>開始日期</nz-form-label>
        <nz-form-control>
          <nz-date-picker id="startDate" formControlName="startDate" nzFormat="YYYY-MM-DD"></nz-date-picker>
        </nz-form-control>
      </nz-form-item>

      <nz-form-item>
        <nz-form-label nzFor="endDate" nzRequired>結束日期</nz-form-label>
        <nz-form-control>
          <nz-date-picker id="endDate" formControlName="endDate" nzFormat="YYYY-MM-DD"></nz-date-picker>
        </nz-form-control>
      </nz-form-item>

      <nz-alert
        *ngIf="form.get('period')?.errors?.['dateRange'] as msg"
        nzType="error"
        [nzMessage]="msg"
        nzShowIcon>
      </nz-alert>
    </div>
  </form>
  `
})
export class PeriodComponent {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    period: this.fb.group({
      startDate: ['2025-09-01', Validators.required],
      endDate: ['2025-09-05', Validators.required]
    }, { validators: [dateRangeValidator] })
  });
}

案例 B:密碼與確認密碼

// validators.ts(節錄)
export const passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const pw = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  return pw && confirm && pw !== confirm ? { passwordMismatch: '兩次輸入的密碼不一致' } : null;
};

// account.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
import { passwordMatchValidator } from './validators';

@Component({
  standalone: true,
  selector: 'app-account',
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule, NzAlertModule],
  template: `
  <form nz-form nzLayout="vertical" [formGroup]="form">
    <div formGroupName="account">
      <nz-form-item>
        <nz-form-label nzFor="password" nzRequired>密碼</nz-form-label>
        <nz-form-control [nzErrorTip]="'請輸入密碼'">
          <input nz-input type="password" id="password" formControlName="password" />
        </nz-form-control>
      </nz-form-item>

      <nz-form-item>
        <nz-form-label nzFor="confirmPassword" nzRequired>確認密碼</nz-form-label>
        <nz-form-control [nzErrorTip]="'請再次輸入密碼'">
          <input nz-input type="password" id="confirmPassword" formControlName="confirmPassword" />
        </nz-form-control>
      </nz-form-item>

      <nz-alert
        *ngIf="form.get('account')?.errors?.['passwordMismatch'] as msg"
        nzType="error"
        [nzMessage]="msg"
        nzShowIcon>
      </nz-alert>
    </div>
  </form>
  `
})
export class AccountComponent {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    account: this.fb.group({
      password: ['', Validators.required],
      confirmPassword: ['', Validators.required]
    }, { validators: [passwordMatchValidator] })
  });
}


3. 擇一必填:Email 或手機至少填一個(群組級驗證)

// validators.ts(節錄)
export function oneOfRequired(keys: string[]): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const hasOne = keys.some(k => {
      const v = group.get(k)?.value;
      return v !== null && v !== undefined && v !== '';
    });
    return hasOne ? null : { oneOfRequired: 'Email 或手機至少填一個' };
  };
}

// contact.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
import { oneOfRequired } from './validators';

@Component({
  standalone: true,
  selector: 'app-contact',
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule, NzAlertModule],
  template: `
  <form nz-form nzLayout="vertical" [formGroup]="form">
    <div formGroupName="contact">
      <nz-form-item>
        <nz-form-label nzFor="email">Email</nz-form-label>
        <nz-form-control [nzErrorTip]="'Email 格式不正確'">
          <input nz-input id="email" formControlName="email" placeholder="example@domain.com" />
        </nz-form-control>
      </nz-form-item>

      <nz-form-item>
        <nz-form-label nzFor="phone">手機</nz-form-label>
        <nz-form-control>
          <input nz-input id="phone" formControlName="phone" placeholder="09xxxxxxxx" />
        </nz-form-control>
      </nz-form-item>

      <nz-alert
        *ngIf="form.get('contact')?.errors?.['oneOfRequired'] as msg"
        nzType="error"
        [nzMessage]="msg"
        nzShowIcon>
      </nz-alert>
    </div>
  </form>
  `
})
export class ContactComponent {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    contact: this.fb.group({
      email: ['', Validators.email],
      phone: ['']
    }, { validators: [oneOfRequired(['email', 'phone'])] })
  });
}


4. 條件必填:題目 A 決定題目 B 是否必填

需求:題目 A = 1 → 題目 B 必填;A = 2 → 題目 B 不必填。

建議實作:動態切換 B 的 Validators(UX 較佳,B 自己會顯示必填錯誤)。

// conditional-required.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';

@Component({
  standalone: true,
  selector: 'app-conditional-required',
  imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzSelectModule, NzInputModule, NzAlertModule],
  template: `
  <form nz-form nzLayout="vertical" [formGroup]="form">
    <nz-form-item>
      <nz-form-label nzFor="questionA" nzRequired>題目 A</nz-form-label>
      <nz-form-control>
        <nz-select id="questionA" formControlName="questionA" nzPlaceHolder="請選擇">
          <nz-option nzValue="1" nzLabel="選項 1"></nz-option>
          <nz-option nzValue="2" nzLabel="選項 2"></nz-option>
        </nz-select>
      </nz-form-control>
    </nz-form-item>

    <nz-form-item>
      <nz-form-label nzFor="questionB" [nzRequired]="isQuestionBRequired">題目 B</nz-form-label>
      <nz-form-control [nzErrorTip]="'當題目 A 選 1,題目 B 為必填'">
        <input nz-input id="questionB" formControlName="questionB" placeholder="請依題目 A 規則填寫" />
      </nz-form-control>
    </nz-form-item>

    <nz-alert
      *ngIf="isQuestionBRequired"
      nzType="info"
      nzMessage="目前題目 A = 1,因此題目 B 為必填。"
      nzShowIcon>
    </nz-alert>
  </form>
  `
})
export class ConditionalRequiredComponent implements OnInit {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    questionA: this.fb.control<string | null>('2', { validators: [Validators.required] }),
    questionB: this.fb.control<string | null>('') // 是否 required 由 A 的值決定
  });

  isQuestionBRequired = false;

  ngOnInit(): void {
    const aCtrl = this.form.get('questionA') as FormControl<string | null>;
    const bCtrl = this.form.get('questionB') as FormControl<string | null>;

    const applyRule = (aVal: string | null) => {
      const shouldRequire = aVal === '1';
      this.isQuestionBRequired = shouldRequire;

      if (shouldRequire) {
        bCtrl.addValidators(Validators.required);
      } else {
        bCtrl.removeValidators(Validators.required);
        // 清除殘留錯誤,避免 UI 一直紅字
        if (bCtrl.hasError('required')) {
          const { required, ...rest } = bCtrl.errors ?? {};
          bCtrl.setErrors(Object.keys(rest).length ? rest : null);
        }
      }
      bCtrl.updateValueAndValidity({ emitEvent: false });
    };

    // 初始化
    applyRule(aCtrl.value);
    // 監聽變化
    aCtrl.valueChanges.subscribe(applyRule);
  }
}

可替代作法:也能寫成 群組級驗證器 檢查 A 與 B 的組合,但 B 本身不會出現「必填」紅框與星號,UX 稍弱。實務上較推薦「動態切換 B 的 Validators」。


5. 錯誤訊息設計與呈現

  • 欄位級錯誤 → 放在欄位下方:<nz-form-control [nzErrorTip]="...">
  • 群組級錯誤 → 集中顯示在該區塊下方:可用 <nz-alert nzType="error"> 顯示整組邏輯錯誤。
  • 提交失敗 → 頁面頂部再顯示一則「整體提示」,並導引使用者到錯誤區。

小技巧:提交時可遞迴 markAllAsDirty(),讓所有錯誤一次浮現;條件必填時要記得在移除必填時清掉舊錯誤。


6. 小結(本篇要點)

  • 欄位級驗證:單一欄位(必填、格式、自訂驗證)。ng-zorro 用 nzErrorTip 直覺呈現。
  • 群組級驗證:跨欄位邏輯(日期區間、密碼一致、擇一必填),錯誤集中顯示在區塊下。
  • 條件必填(A 控 B):建議 動態切換 B 的 Validators,B 能正確顯示必填狀態(UX 佳)。
  • 自訂驗證時直接回傳可讀字串,UI 只要顯示字串即可。

👉 下一篇將進一步示範 FormArray 與動態集合


上一篇
Day 21:Angular Reactive Forms – 表單結構基礎 — 單層 vs 巢狀
下一篇
Day 23:Angular Reactive Forms – FormArray 與動態集合
系列文
Angular 進階實務 30天23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言